fix(deep-links): report plugin integrity failures as tampering, not a bad link - #152
Merged
Conversation
… bad link A plugin-install confirm sheet surfaced every accept failure as the generic "deep link install failed", so PluginHashMismatchError and MinAppVersionError were misreported. A hash mismatch is the user's only tamper signal. Extract pluginInstallErrorMessage(e, t, fallbackKey), matched on error type rather than message, and use it from both the settings install path and the confirm sheet via a new optional errorMessage on ConfirmSpec. Also add explicit .disabled assertions to the confirm-sheet negative tests: they clicked an already-disabled accept button, so the "installs/invites nobody" assertions could not fail.
The sheet stored its error already translated, which forced `t` into the load effect's dependencies. `t` is a new function on every locale change, so changing language over an open sheet ran `load` a second time — another searchUsers or manifest fetch — without resetting `loading` or `loadFailed`, leaving accept enabled over a stale result. Errors are now held as a key plus its parameters and translated at render, so `t` leaves the deps. pluginInstallErrorMessage returns that descriptor rather than a string, which is also what lets it carry versionUnsupported's `version`. The effect additionally resets its own state, since a redelivered link can change the intent's identity without remounting the sheet.
… dismissed one dismissPrompt promoted the next queued intent in the same tick, and `join` has no load step, so its accept button was live in the first frame at the same screen position as the button just clicked. A double-click on Install could land its second half on Join. Queue promotion now waits 300ms. Only a sheet arriving behind another pays it: a link that arrives on its own still prompts immediately, and unprompted intents are untouched.
This was referenced Aug 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-ups 1 and 3 from the #150 confirm-routes review. Part of #144.
An integrity mismatch read as "bad link"
A plugin-install confirm sheet surfaced every accept failure as the generic
settings.plugins.deepLinkInstall.failed, soPluginHashMismatchErrorandMinAppVersionErrorwere misreported. A hash mismatch is the user's only tamper signal, and it must not look like a link that simply did not work. The settings click path already distinguished both, so the mapping now exists once:src/plugins/installErrors.ts—pluginInstallErrorMessage(e, t, fallbackKey), matched on error type rather than message, per the project rule.ConfirmSpecgainserrorMessage?: (e, t, fallbackKey) => string;plugin-installsets it topluginInstallErrorMessagedirectly.DeepLinkConfirmModal's twosetErrorsites both go through a module-scopefailureMessage(spec, e, t)— module scope so theloadeffect gains no dependency.PluginsSection.notifyError's inline three-way ternary collapses onto the shared helper.The review proposed
errorKeyFor?: (e) => string. A bare key cannot carryversionUnsupported's{version}interpolation, so the seam takestand returns the message; passing the fallback key in meanserrorKeyis still stated only once.Two tests that could not fail
Both
invitenegative tests clicked an accept button that was alreadydisabled, so their "invites nobody" assertions were vacuous. Explicit.disabledassertions added — and to the snippet and plugin negative tests, which had the identical hole.New coverage: hash mismatch, min-version, and generic failure each produce their own message in the sheet.
Verification
node ./node_modules/typescript/bin/tsc --noEmit -p tsconfig.json— exit 0, no output.CI=true pnpm vitest run—1 failed | 3713 passed. The failure istests/pluginBundleBuild.test.tstiming out at 5000ms under load; it passes 8/8 run alone. Known flake on pristinedev, unrelated to this diff.Not covered: the sheets have still never been opened in a running app.
Added after review: follow-ups 2 and 4
A locale change re-ran the sheet's
loadThe sheet stored its error already translated, which forced
tinto the load effect's dependencies.tis a new function on every locale change, so changing language over an open sheet ranloadagain — a secondsearchUsersor manifest fetch — without resettingloadingorloadFailed, leaving accept enabled over a stale result.Errors are now held as a key plus parameters and translated at render, so
tleaves the deps.pluginInstallErrorMessagereturns that descriptor rather than a string, which is also what lets it carryversionUnsupported'sversion. The effect also resets its own state, since a redelivered link can change the intent's identity without remounting the sheet. A test pins the call count: it was 2, it is now 1.A double-click could cross two sheets
dismissPromptpromoted the next queued intent in the same tick, andjoinhas noload, so its accept button was live in the first frame at the same screen position as the button just clicked — a double-click on Install could land its second half on Join.Queue promotion now waits 300ms, and only a sheet arriving behind another pays it: a link that arrives on its own still prompts immediately, and unprompted intents are untouched. The delay was chosen over arming every sheet's accept button late, so a single link stays instant.
Re-verified:
tsc --noEmitexit 0 with no output; fullCI=true pnpm vitest run=1 failed | 3715 passed, the failure again beingtests/pluginBundleBuild.test.tsat 5000ms, which passes 8/8 alone.